<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8" />
  <title>Canvas 3D 点云心形</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    html,
    body {
      width: 100%;
      height: 100%;
      background: radial-gradient(circle at 50% 40%, #101018, #050508 70%);
      overflow: hidden;
      font-family: system-ui, Segoe UI, Helvetica, Arial, sans-serif;
    }

    canvas {
      display: block;
      width: 100%;
      height: 100%;
    }

    .ui {
      position: fixed;
      top: 12px;
      left: 12px;
      color: #ccc;
      font-size: 12px;
      line-height: 1.4;
      user-select: none;
      background: rgba(0, 0, 0, .35);
      padding: 8px 10px;
      border: 1px solid rgba(255, 255, 255, .08);
      border-radius: 6px;
      backdrop-filter: blur(4px);
    }

    .ui a {
      color: #ff6fa8;
      text-decoration: none;
    }

    .ui a:hover {
      text-decoration: underline;
    }

  </style>
</head>

<body>
  <canvas id="c"></canvas>
  <script>
    ; (() => {
      const HEART_COLOR = '#EEAEEE';
      const IMAGE_ENLARGE = 13; // 放大比例
      const BASE_POINTS = 1000; // 轮廓随机点数量 (Python build 里 2000)
      const EDGE_DIFFUSE_REPEAT = 3; // 每个轮廓点扩散次数
      const CENTER_DIFFUSE_COUNT = 1000; // 再扩散次数
      const FRAMES = 120; // 预计算帧数 (更多中间态)
      const SPEED = 0.12; // 帧推进步长 (减小=更慢)
      const BEAT_DENOM = 40; // 心跳周期控制: 真实周期帧数 = BEAT_DENOM / 2 (这里=20 逻辑帧)
      const CANVAS = document.getElementById('c');
      const CTX = CANVAS.getContext('2d');

      function resize() {
        const dpr = window.devicePixelRatio || 1;
        CANVAS.width = window.innerWidth * dpr;
        CANVAS.height = window.innerHeight * dpr;
        CANVAS.style.width = window.innerWidth + 'px';
        CANVAS.style.height = window.innerHeight + 'px';
        CTX.scale(dpr, dpr);
      }
      resize();
      window.addEventListener('resize', resize);

      const W = () => window.innerWidth;
      const H = () => window.innerHeight;
      const CX = () => W() / 2;
      const CY = () => H() / 2;

      // ========= 数学函数 =========
      function heartFunction(t) {
        const x = 17 * Math.pow(Math.sin(t), 3);
        // 与 Python 相同: -(16 cos t -5 cos 2t -2 cos3t - cos3t) => -(16cos t -5cos2t -3cos3t)
        const y = -(16 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(3 * t));
        // 放大 + 平移居中
        return [x * IMAGE_ENLARGE + CX(), y * IMAGE_ENLARGE + CY()];
      }

      function scatterInside(x, y, beta = 0.15) {
        const ratioX = -beta * Math.log(Math.random());
        const ratioY = -beta * Math.log(Math.random());
        const dx = ratioX * (x - CX());
        const dy = ratioY * (y - CY());
        return [x - dx, y - dy];
      }

      function shrink(x, y, ratio) {
        const force = -1 / Math.pow((Math.pow(x - CX(), 2) + Math.pow(y - CY(), 2)), 0.6);
        const dx = ratio * force * (x - CX());
        const dy = ratio * force * (y - CY());
        return [x - dx, y - dy];
      }

      function curve(p) {
        return 2 * (2 * Math.sin(4 * p)) / (2 * Math.PI); // 与 Python 保持
      }

      // ========= 数据结构 =========
      const contourPoints = Object.create(null); // 原始轮廓 (key -> true)
      const edgeDiffuse = Object.create(null);
      const centerDiffuse = Object.create(null);
      const framePoints = []; // 每帧点数组

      function key(x, y) { return (x | 0) + ',' + (y | 0); }

      // ========= 构建静态点集 =========
      function build() {
        // 轮廓
        for (let i = 0; i < BASE_POINTS; i++) {
          const t = Math.random() * Math.PI * 2; // 0..2π
          const [x, y] = heartFunction(t);
          contourPoints[key(x, y)] = true;
        }
        // 边缘扩散
        const contourArr = Object.keys(contourPoints).map(s => s.split(',').map(Number));
        for (const [x0, y0] of contourArr) {
          for (let i = 0; i < EDGE_DIFFUSE_REPEAT; i++) {
            const [x, y] = scatterInside(x0, y0, 0.05);
            edgeDiffuse[key(x, y)] = true;
          }
        }
        // 中心扩散
        for (let i = 0; i < CENTER_DIFFUSE_COUNT; i++) {
          const [x0, y0] = contourArr[(Math.random() * contourArr.length) | 0];
          const [x, y] = scatterInside(x0, y0, 0.27);
          centerDiffuse[key(x, y)] = true;
        }
      }
      build();
      const contourArr = Object.keys(contourPoints).map(s => s.split(',').map(Number));
      const edgeArr = Object.keys(edgeDiffuse).map(s => s.split(',').map(Number));
      const centerArr = Object.keys(centerDiffuse).map(s => s.split(',').map(Number));

      // ========= 动态帧计算 =========
      function calcFrame(f) {
        // 使用更慢的心跳相位 (f / BEAT_DENOM)
        const phase = f / BEAT_DENOM * Math.PI;
        const beat = curve(phase); // [-? , ?]
        const ratio = 10 * beat; // 跳动幅度
        const haloRadius = Math.floor(4 + 6 * (1 + beat));
        const haloNumber = Math.floor(3000 + 4000 * Math.pow(Math.abs(beat), 2));
        const points = [];

        // 光环 (使用集合避免重复)
        const haloSet = Object.create(null);
        for (let i = 0; i < haloNumber; i++) {
          const t = Math.random() * Math.PI * 2;
          let [x, y] = heartFunction(t);
          [x, y] = shrink(x, y, haloRadius);
          const k = key(x, y);
          if (!haloSet[k]) {
            haloSet[k] = true;
            // 加四个对角扩散点 (与 Python 版本类似)
            const size = Math.random() < 0.66 ? 1 : 2;
            const randOff = () => (Math.random() * 120 - 60) | 0; // -60~60
            const baseX = x + randOff();
            const baseY = y + randOff();
            points.push([baseX, baseY, size, true]);
            points.push([baseX + 20, baseY + 20, size, true]);
            points.push([baseX - 20, baseY - 20, size, true]);
            points.push([baseX + 20, baseY - 20, size, true]);
            points.push([baseX - 20, baseY + 20, size, true]);
          }
        }

        function calcPosition(x, y) {
          const force = 1 / Math.pow((Math.pow(x - CX(), 2) + Math.pow(y - CY(), 2)), 0.420);
          // 降低随机抖动幅度
          const dx = ratio * force * (x - CX()) + (Math.random() * 0.8 - 0.4);
          const dy = ratio * force * (y - CY()) + (Math.random() * 0.8 - 0.4);
          return [x - dx, y - dy];
        }

        // 轮廓
        for (const [x0, y0] of contourArr) {
          const [x, y] = calcPosition(x0, y0);
          points.push([x, y, (Math.random() * 3 | 0) + 1, false]);
        }
        // 边缘扩散
        for (const [x0, y0] of edgeArr) {
          const [x, y] = calcPosition(x0, y0);
          points.push([x, y, (Math.random() * 2 | 0) + 1, false]);
        }
        // 中心扩散
        for (const [x0, y0] of centerArr) {
          const [x, y] = calcPosition(x0, y0);
          points.push([x, y, (Math.random() * 2 | 0) + 1, false]);
        }
        return points;
      }

      for (let f = 0; f < FRAMES; f++) {
        framePoints[f] = calcFrame(f);
      }

      // ========= 渲染 =========
      let frameProgress = 0;
      function render() {
        requestAnimationFrame(render);
        frameProgress += SPEED; // 按小步进推进
        if (frameProgress >= FRAMES) frameProgress -= FRAMES; // 循环
        CTX.clearRect(0, 0, W(), H());
        const pts = framePoints[Math.floor(frameProgress) % FRAMES];
        // 背景轻微渐隐 (尾迹感) 可选: CTX.fillStyle='rgba(0,0,0,0.15)'; CTX.fillRect(0,0,W(),H());
        for (const [x, y, size, halo] of pts) {
          if (halo) {
            // 光环点颜色淡一些
            CTX.fillStyle = 'rgba(255,190,255,0.12)';
          } else {
            CTX.fillStyle = HEART_COLOR;
          }
          CTX.fillRect(x, y, size, size);
        }
      }
      render();
    })();
  </script>
</body>

</html>
